cainiaoblog 23.2.346 → 23.2.347

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"cainiaoblog","version":"23.2.346","description":"cainiaoblog","main":"index.js","directories":{"lib":"lib"},"scripts":{"test":"echo \"Error: no test specified\" && exit 1"},"publishConfig":{"registry":"https://registry.npmjs.org"},"repository":{"type":"git","url":"git+https://github.com/npljy/npljy.github.io.git"},"keywords":["cainiaoblog"],"author":"cainiao","license":"ISC","bugs":{"url":"https://github.com/npljy/npljy.github.io/issues"},"homepage":"https://github.com/npljy/npljy.github.io#readme"}
1
+ {"name":"cainiaoblog","version":"23.2.347","description":"cainiaoblog","main":"index.js","directories":{"lib":"lib"},"scripts":{"test":"echo \"Error: no test specified\" && exit 1"},"publishConfig":{"registry":"https://registry.npmjs.org"},"repository":{"type":"git","url":"git+https://github.com/npljy/npljy.github.io.git"},"keywords":["cainiaoblog"],"author":"cainiao","license":"ISC","bugs":{"url":"https://github.com/npljy/npljy.github.io/issues"},"homepage":"https://github.com/npljy/npljy.github.io#readme"}
package/search.xml CHANGED
@@ -2535,7 +2535,7 @@
2535
2535
  <link href="/article/14192.html"/>
2536
2536
  <url>/article/14192.html</url>
2537
2537
 
2538
- <content type="html"><![CDATA[<p><code>原文地址:https://juejin.im/post/5dff8a26e51d4558105420ed</code><br>这段时间面试官都挺忙的,频频出现在博客文章标题,虽然我不是特别想蹭热度,但是实在想不到好的标题了-。-,蹭蹭就蹭蹭 :)<br>事实上我在面试的时候确实被问到了这个问题,而且是一道在线 coding 的编程题,当时虽然思路正确,可惜最终也并不算完全答对<br>结束后花了一段时间整理了下思路,那么究竟该如何实现一个大文件上传,以及在上传中如何实现断点续传的功能呢?<br>本文将从零搭建前端和服务端,实现一个大文件上传和断点续传的 demo</p><span id="more"></span><p>前端:<code>vue``element-ui</code><br>服务端:<code>nodejs</code></p><p><code>文章有误解的地方,欢迎指出,将在第一时间改正,有更好的实现方式希望留下你的评论</code></p><h1 id="大文件上传"><a href="#大文件上传" class="headerlink" title="大文件上传"></a>大文件上传</h1><h2 id="整体思路"><a href="#整体思路" class="headerlink" title="整体思路"></a>整体思路</h2><h3 id="前端"><a href="#前端" class="headerlink" title="前端"></a>前端</h3><p>前端大文件上传网上的大部分文章已经给出了解决方案,核心是<code>利用 Blob.prototype.slice</code> 方法,和数组的 slice 方法相似,调用的 slice 方法可以返回<code>原文件的某个切片</code></p><p>这样我们就可以根据预先设置好的切片最大数量将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片,这样从原本传一个大文件,变成了<code>同时</code>传多个小的文件切片,可以大大减少上传时间</p><p>另外由于是并发,传输到服务端的顺序可能会发生变化,所以我们还需要给每个切片记录顺序</p><h3 id="服务端"><a href="#服务端" class="headerlink" title="服务端"></a>服务端</h3><p>服务端需要负责接受这些切片,并在接收到所有切片后<code>合并</code>切片</p><p>这里又引伸出两个问题</p><ol><li>何时合并切片,即切片什么时候传输完成</li><li>如何合并切片</li></ol><p>第一个问题需要前端进行配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并,也可以额外发一个请求主动通知服务端进行切片的合并</p><p>第二个问题,具体如何合并切片呢?这里可以使用 nodejs 的 api <code>fs.appendFileSync</code>,它可以同步地将数据追加到指定文件,也就是说,当服务端接受到所有切片后,先创建一个最终的文件,然后将所有切片逐步合并到这个文件中</p><p><code>talk is cheap,show me the code</code>,接着我们用代码实现上面的思路</p><h2 id="前端部分"><a href="#前端部分" class="headerlink" title="前端部分"></a>前端部分</h2><p>前端使用 Vue 作为开发框架,对界面没有太大要求,原生也可以,考虑到美观使用 element-ui 作为 UI 框架</p><h3 id="上传控件"><a href="#上传控件" class="headerlink" title="上传控件"></a>上传控件</h3><p>首先创建选择文件的控件,监听 change 事件以及上传按钮</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line"> &lt;div&gt;</span><br><span class="line"> &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line"> &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line"> &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">export default &#123;</span><br><span class="line"> data: () =&gt; (&#123;</span><br><span class="line"> container: &#123;</span><br><span class="line"> file: null</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;),</span><br><span class="line"> methods: &#123;</span><br><span class="line"> handleFileChange(e) &#123;</span><br><span class="line"> const [file] = e.target.files;</span><br><span class="line"> if (!file) return;</span><br><span class="line"> Object.assign(this.$data, this.$options.data());</span><br><span class="line"> this.container.file = file;</span><br><span class="line"> &#125;,</span><br><span class="line"> async handleUpload() &#123;&#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p><img src="https://user-gold-cdn.xitu.io/2020/1/7/16f7ff98adb6bcdd?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h3 id="请求逻辑"><a href="#请求逻辑" class="headerlink" title="请求逻辑"></a>请求逻辑</h3><p>考虑到通用性,这里没有用第三方的请求库,而是用原生 XMLHttpRequest 做一层简单的封装来发请求</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">request(&#123;</span><br><span class="line"> url,</span><br><span class="line"> method = &quot;post&quot;,</span><br><span class="line"> data,</span><br><span class="line"> headers = &#123;&#125;,</span><br><span class="line"> requestList</span><br><span class="line"> &#125;) &#123;</span><br><span class="line"> returnnewPromise(resolve =&gt; &#123;</span><br><span class="line"> const xhr = new XMLHttpRequest();</span><br><span class="line"> xhr.open(method, url);</span><br><span class="line"> Object.keys(headers).forEach(key =&gt;</span><br><span class="line"> xhr.setRequestHeader(key, headers[key])</span><br><span class="line"> );</span><br><span class="line"> xhr.send(data);</span><br><span class="line"> xhr.onload = e =&gt; &#123;</span><br><span class="line"> resolve(&#123;</span><br><span class="line"> data: e.target.response</span><br><span class="line"> &#125;);</span><br><span class="line"> &#125;;</span><br><span class="line"> &#125;);</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><h3 id="上传切片"><a href="#上传切片" class="headerlink" title="上传切片"></a>上传切片</h3><p>接着实现比较重要的上传功能,上传需要做两件事</p><ul><li>对文件进行切片</li><li>将切片传输给服务端</li></ul><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line"> &lt;div&gt;</span><br><span class="line"> &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line"> &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line"> &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">+ const LENGTH = 10; // 切片数量</span><br><span class="line"></span><br><span class="line">export default &#123;</span><br><span class="line"> data: () =&gt; (&#123;</span><br><span class="line"> container: &#123;</span><br><span class="line"> file: null,</span><br><span class="line">+ data: []</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;),</span><br><span class="line"> methods: &#123;</span><br><span class="line"> request() &#123;&#125;,</span><br><span class="line"> handleFileChange() &#123;&#125;,</span><br><span class="line">+ // 生成文件切片</span><br><span class="line">+ createFileChunk(file, length = LENGTH) &#123;</span><br><span class="line">+ const fileChunkList = [];</span><br><span class="line">+ const chunkSize = Math.ceil(file.size / length);</span><br><span class="line">+ let cur = 0;</span><br><span class="line">+ while (cur &lt; file.size) &#123;</span><br><span class="line">+ fileChunkList.push(&#123; file: file.slice(cur, cur + chunkSize) &#125;);</span><br><span class="line">+ cur += chunkSize;+ &#125;</span><br><span class="line">+ return fileChunkList;+ &#125;,</span><br><span class="line">+ // 上传切片</span><br><span class="line">+ async uploadChunks() &#123;</span><br><span class="line">+ const requestList = this.data</span><br><span class="line">+ .map((&#123; chunk,hash &#125;) =&gt; &#123;</span><br><span class="line">+ const formData = new FormData();</span><br><span class="line">+ formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line">+ formData.append(&quot;hash&quot;, hash);</span><br><span class="line">+ formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line">+ return &#123; formData &#125;;</span><br><span class="line">+ &#125;)</span><br><span class="line">+ .map(async (&#123; formData &#125;) =&gt;</span><br><span class="line">+ this.request(&#123;</span><br><span class="line">+ url: &quot;http://localhost:3000&quot;,</span><br><span class="line">+ data: formData+ &#125;)</span><br><span class="line">+ );</span><br><span class="line">+ await Promise.all(requestList); // 并发切片</span><br><span class="line">+ &#125;,</span><br><span class="line">+ async handleUpload() &#123;</span><br><span class="line">+ if (!this.container.file) return;</span><br><span class="line">+ const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">+ this.data = fileChunkList.map((&#123; file &#125;,index) =&gt; (&#123;</span><br><span class="line">+ chunk: file,</span><br><span class="line">+ hash: this.container.file.name + &quot;-&quot; + index // 文件名 + 数组下标</span><br><span class="line">+ &#125;));</span><br><span class="line">+ await this.uploadChunks();</span><br><span class="line">+ &#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;</span><br></pre></td></tr></table></figure><p>当点击上传按钮时,调用 <code>createFileChunk</code> 将文件切片,切片数量通过一个常量 Length 控制,这里设置为 10,即将文件分成 10 个切片上传</p><p>createFileChunk 内使用 while 循环和 slice 方法将切片放入 <code>fileChunkList</code> 数组中返回</p><p>在生成文件切片时,需要给每个切片一个标识作为 hash,这里暂时使用<code>文件名 + 下标</code>,这样后端可以知道当前切片是第几个切片,用于之后的合并切片</p><p>随后调用 <code>uploadChunks</code> 上传所有的文件切片,将文件切片,切片 hash,以及文件名放入 FormData 中,再调用上一步的 <code>request</code> 函数返回一个 proimise,最后调用 Promise.all 并发上传所有的切片</p><h3 id="发送合并请求"><a href="#发送合并请求" class="headerlink" title="发送合并请求"></a>发送合并请求</h3><p>这里使用整体思路中提到的第二种合并切片的方式,即前端主动通知服务端进行合并,所以前端还需要额外发请求,服务端接受到这个请求时主动合并切片</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line"> &lt;div&gt;</span><br><span class="line"> &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line"> &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line"> &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">export default &#123;</span><br><span class="line"> data: () =&gt; (&#123;</span><br><span class="line"> container: &#123;</span><br><span class="line"> file: null</span><br><span class="line"> &#125;,</span><br><span class="line"> data: []</span><br><span class="line"> &#125;),</span><br><span class="line"> methods: &#123;</span><br><span class="line"> request() &#123;&#125;,</span><br><span class="line"> handleFileChange() &#123;&#125;,</span><br><span class="line"> createFileChunk() &#123;&#125;,</span><br><span class="line"> // 上传切片,同时过滤已上传的切片</span><br><span class="line"> async uploadChunks() &#123;</span><br><span class="line"> const requestList = this.data</span><br><span class="line"> .map((&#123; chunk,hash &#125;) =&gt; &#123;</span><br><span class="line"> const formData = new FormData();</span><br><span class="line"> formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line"> formData.append(&quot;hash&quot;, hash);</span><br><span class="line"> formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line"> return &#123; formData &#125;;</span><br><span class="line"> &#125;)</span><br><span class="line"> .map(async (&#123; formData &#125;) =&gt;</span><br><span class="line"> this.request(&#123;</span><br><span class="line"> url: &quot;http://localhost:3000&quot;,</span><br><span class="line"> data: formData</span><br><span class="line"> &#125;)</span><br><span class="line"> );</span><br><span class="line"> await Promise.all(requestList);</span><br><span class="line">+ // 合并切片+ await this.mergeRequest();</span><br><span class="line"> &#125;,</span><br><span class="line">+ async mergeRequest() &#123;</span><br><span class="line">+ await this.request(&#123;</span><br><span class="line">+ url: &quot;http://localhost:3000/merge&quot;,</span><br><span class="line">+ headers: &#123;</span><br><span class="line">+ &quot;content-type&quot;: &quot;application/json&quot;</span><br><span class="line">+ &#125;,</span><br><span class="line">+ data: JSON.stringify(&#123;</span><br><span class="line">+ filename: this.container.file.name+ &#125;)</span><br><span class="line">+ &#125;);</span><br><span class="line">+ &#125;,</span><br><span class="line"> async handleUpload() &#123;&#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;</span><br></pre></td></tr></table></figure><h2 id="服务端部分"><a href="#服务端部分" class="headerlink" title="服务端部分"></a>服务端部分</h2><p>简单使用 http 模块搭建服务端</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">const http = require(&quot;http&quot;);</span><br><span class="line">const server = http.createServer();</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);</span><br><span class="line"> if (req.method === &quot;OPTIONS&quot;) &#123;</span><br><span class="line"> res.status = 200;</span><br><span class="line"> res.end();</span><br><span class="line"> return;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure><h3 id="接受切片"><a href="#接受切片" class="headerlink" title="接受切片"></a>接受切片</h3><p>使用 <code>multiparty</code> 包处理前端传来的 FormData</p><p>在 multiparty.parse 的回调中,files 参数保存了 FormData 中文件,fields 参数保存了 FormData 中非文件的字段</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line">const http = require(&quot;http&quot;);</span><br><span class="line">const path = require(&quot;path&quot;);</span><br><span class="line">const fse = require(&quot;fs-extra&quot;);</span><br><span class="line">const multiparty = require(&quot;multiparty&quot;);</span><br><span class="line"></span><br><span class="line">const server = http.createServer();</span><br><span class="line">+ const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);</span><br><span class="line"> if (req.method === &quot;OPTIONS&quot;) &#123;</span><br><span class="line"> res.status = 200;</span><br><span class="line"> res.end();</span><br><span class="line"> return;</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line">+ const multipart = new multiparty.Form();</span><br><span class="line">+ multipart.parse(req, async (err, fields, files) =&gt; &#123;</span><br><span class="line">+ if (err) &#123;</span><br><span class="line">+ return;</span><br><span class="line">+ &#125;</span><br><span class="line">+ const [chunk] = files.chunk;</span><br><span class="line">+ const [hash] = fields.hash;</span><br><span class="line">+ const [filename] = fields.filename;</span><br><span class="line">+ const chunkDir = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;</span><br><span class="line">+ // 切片目录不存在,创建切片目录</span><br><span class="line">+ if (!fse.existsSync(chunkDir)) &#123;</span><br><span class="line">+ await fse.mkdirs(chunkDir);</span><br><span class="line">+ &#125;</span><br><span class="line">+ // fs-extra 专用方法,类似 fs.rename 并且跨平台</span><br><span class="line">+ // fs-extra 的 rename 方法 windows 平台会有权限问题</span><br><span class="line">+ // https://github.com/meteor/meteor/issues/7852#issuecomment-255767835</span><br><span class="line">+ await fse.move(chunk.path, `$&#123;chunkDir&#125;/$&#123;hash&#125;`);</span><br><span class="line">+ res.end(&quot;received file chunk&quot;);</span><br><span class="line">+ &#125;);</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure><p><img src="https://user-gold-cdn.xitu.io/2020/1/11/16f905fb6a626f47?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="image-20200110215559194"><br>查看 multiparty 处理后的 chunk 对象,path 是存储临时文件的路径,size 是临时文件大小,在 multiparty 文档中提到可以使用 fs.rename(由于我用的是 fs-extra,它的 rename 方法 windows 平台权限问题,所以换成了 fse.move) 移动临时文件,即移动文件切片</p><p>在接受文件切片时,需要先创建存储切片的文件夹,由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片从临时路径移动切片文件夹中,最后的结果如下<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f83c3caac8eb65?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h3 id="合并切片"><a href="#合并切片" class="headerlink" title="合并切片"></a>合并切片</h3><p>在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并</p><pre><code>const http = require(&quot;http&quot;);const path = require(&quot;path&quot;);const fse = require(&quot;fs-extra&quot;);const server = http.createServer();const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录+ const resolvePost = req =&gt;+ new Promise(resolve =&gt; &#123;+ let chunk = &quot;&quot;;+ req.on(&quot;data&quot;, data =&gt; &#123;+ chunk += data;+ &#125;);+ req.on(&quot;end&quot;, () =&gt; &#123;+ resolve(JSON.parse(chunk));+ &#125;);+ &#125;);+ // 合并切片+ const mergeFileChunk = async (filePath, filename) =&gt; &#123;+ const chunkDir = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;+ const chunkPaths = await fse.readdir(chunkDir);+ await fse.writeFile(filePath, &quot;&quot;);+ chunkPaths.forEach(chunkPath =&gt; &#123;+ fse.appendFileSync(filePath, fse.readFileSync(`$&#123;chunkDir&#125;/$&#123;chunkPath&#125;`));+ fse.unlinkSync(`$&#123;chunkDir&#125;/$&#123;chunkPath&#125;`);+ &#125;);+ fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录+ &#125;;server.on(&quot;request&quot;, async (req, res) =&gt; &#123; res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;); res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;); if (req.method === &quot;OPTIONS&quot;) &#123; res.status = 200; res.end(); return; &#125;+ if (req.url === &quot;/merge&quot;) &#123;+ const data = await resolvePost(req);+ const &#123; filename &#125; = data;+ const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;+ await mergeFileChunk(filePath, filename);+ res.end(+ JSON.stringify(&#123;+ code: 0,+ message: &quot;file merged success&quot;+ &#125;)+ );+ &#125;&#125;);server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));复制代码</code></pre><p>由于前端在发送合并请求时会携带文件名,服务端根据文件名可以找到上一步创建的切片文件夹</p><p>接着使用 fs.writeFileSync 先创建一个空文件,这个空文件的文件名就是<strong>切片文件夹名 + 后缀名</strong>组合而成,随后通过 fs.appendFileSync 从切片文件夹中不断将切片合并到空文件中,每次合并完成后删除这个切片,等所有切片都合并完毕后最后删除切片文件夹<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f8414c7fae1c1f?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>至此一个简单的大文件上传就完成了,接下来我们再此基础上扩展一些额外的功能</p><h2 id="显示上传进度条"><a href="#显示上传进度条" class="headerlink" title="显示上传进度条"></a>显示上传进度条</h2><p>上传进度分两种,一个是每个切片的上传进度,另一个是整个文件的上传进度,而整个文件的上传进度是基于每个切片上传进度计算而来,所以我们先实现切片的上传进度</p><h3 id="切片进度条"><a href="#切片进度条" class="headerlink" title="切片进度条"></a>切片进度条</h3><p>XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,我们在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件</p><pre><code> // xhr request(&#123; url, method = &quot;post&quot;, data, headers = &#123;&#125;,+ onProgress = e =&gt; e, requestList &#125;) &#123; return new Promise(resolve =&gt; &#123; const xhr = new XMLHttpRequest();+ xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key =&gt; xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e =&gt; &#123; resolve(&#123; data: e.target.response &#125;); &#125;; &#125;); &#125;复制代码</code></pre><p>由于每个切片都需要触发独立的监听事件,所以还需要一个工厂函数,根据传入的切片返回不同的监听函数</p><p>在原先的前端上传逻辑中新增监听函数部分</p><pre><code> // 上传切片,同时过滤已上传的切片 async uploadChunks(uploadedList = []) &#123; const requestList = this.data+ .map((&#123; chunk,hash,index &#125;) =&gt; &#123; const formData = new FormData(); formData.append(&quot;chunk&quot;, chunk); formData.append(&quot;hash&quot;, hash); formData.append(&quot;filename&quot;, this.container.file.name);+ return &#123; formData,index &#125;; &#125;)+ .map(async (&#123; formData,index &#125;) =&gt; this.request(&#123; url: &quot;http://localhost:3000&quot;, data: formData,+ onProgress: this.createProgressHandler(this.data[index]), &#125;) ); await Promise.all(requestList); // 合并切片 await this.mergeRequest(); &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.data = fileChunkList.map((&#123; file &#125;,index) =&gt; (&#123; chunk: file,+ index, hash: this.container.file.name + &quot;-&quot; + index+ percentage:0 &#125;)); await this.uploadChunks(); &#125; + createProgressHandler(item) &#123;+ return e =&gt; &#123;+ item.percentage = parseInt(String((e.loaded / e.total) * 100));+ &#125;;+ &#125;复制代码</code></pre><p>每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可</p><h3 id="文件进度条"><a href="#文件进度条" class="headerlink" title="文件进度条"></a>文件进度条</h3><p>将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 计算属性</p><pre><code> computed: &#123; uploadPercentage() &#123; if (!this.container.file || !this.data.length) return0; const loaded = this.data .map(item =&gt; item.size * item.percentage) .reduce((acc, cur) =&gt; acc + cur); returnparseInt((loaded / this.container.file.size).toFixed(2)); &#125; &#125;复制代码</code></pre><p>最终视图如下<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84ad6b4848423?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h1 id="断点续传"><a href="#断点续传" class="headerlink" title="断点续传"></a>断点续传</h1><p>断点续传的原理在于前端&#x2F;服务端需要<code>记住</code>已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能</p><ul><li>前端使用 localStorage 记录已上传的切片 hash</li><li>服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片</li></ul><p>第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选取后者</p><h2 id="生成-hash"><a href="#生成-hash" class="headerlink" title="生成 hash"></a>生成 hash</h2><p>无论是前端还是服务端,都必须要生成文件和切片的 hash,<code>之前我们使用文件名 + 切片下标作为切片 hash</code>,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是<code>根据文件内容生成 hash</code>,所以我们修改一下 hash 的生成规则</p><p>这里用到另一个库 <code>spark-md5</code>,它可以根据文件内容计算出文件的 hash 值,另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会<code>引起 UI 的阻塞</code>,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互</p><p>由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了<code>importScripts</code> 函数用于导入外部脚本,通过它导入 spark-md5</p><pre><code>// /public/hash.jsself.importScripts(&quot;/spark-md5.min.js&quot;); // 导入脚本// 生成文件 hashself.onmessage = e =&gt; &#123; const &#123; fileChunkList &#125; = e.data; const spark = new self.SparkMD5.ArrayBuffer(); let percentage = 0; let count = 0; const loadNext = index =&gt; &#123; const reader = new FileReader(); reader.readAsArrayBuffer(fileChunkList[index].file); reader.onload = e =&gt; &#123; count++; spark.append(e.target.result); if (count === fileChunkList.length) &#123; self.postMessage(&#123; percentage: 100, hash: spark.end() &#125;); self.close(); &#125; else &#123; percentage += 100 / fileChunkList.length; self.postMessage(&#123; percentage &#125;); // 递归计算下一个切片 loadNext(count); &#125; &#125;; &#125;; loadNext(0);&#125;;复制代码</code></pre><p>在 worker 线程中,接受文件切片 fileChunkList,利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程</p><p><code>spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash,具体可以看官方文档</code></p><p><a href="https://www.npmjs.com/package/spark-md5">spark-md5</a></p><p>接着编写主线程与 worker 线程通讯的逻辑</p><pre><code>+ // 生成文件 hash(web-worker)+ calculateHash(fileChunkList) &#123;+ return new Promise(resolve =&gt; &#123;+ // 添加 worker 属性+ this.container.worker = new Worker(&quot;/hash.js&quot;);+ this.container.worker.postMessage(&#123; fileChunkList &#125;);+ this.container.worker.onmessage = e =&gt; &#123;+ const &#123; percentage, hash &#125; = e.data;+ this.hashPercentage = percentage;+ if (hash) &#123;+ resolve(hash);+ &#125;+ &#125;;+ &#125;); &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file);+ this.container.hash = await this.calculateHash(fileChunkList); this.data = fileChunkList.map((&#123; file &#125;,index) =&gt; (&#123;+ fileHash: this.container.hash, chunk: file, hash: this.container.file.name + &quot;-&quot; + index, // 文件名 + 数组下标 percentage:0 &#125;)); await this.uploadChunks(); &#125; 复制代码</code></pre><p>主线程使用 <code>postMessage</code> 给 worker 线程传入所有切片 fileChunkList,并监听 worker 线程发出的 postMessage 事件拿到文件 hash</p><p>加上显示计算 hash 的进度条,看起来像这样<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84ae4f233e891?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>至此前端需要将之前用文件名作为 hash 的地方改写为 workder 返回的这个 hash<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84b3bba953304?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>服务端则使用 hash 作为切片文件夹名,hash + 下标作为切片名,hash + 扩展名作为文件名,没有新增的逻辑<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84b50117e4db2?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84b33bbceaa48?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h2 id="文件秒传"><a href="#文件秒传" class="headerlink" title="文件秒传"></a>文件秒传</h2><p>在实现断点续传前先简单介绍一下文件秒传</p><p>所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户<code>再次上传</code>时会直接提示上传成功</p><p>文件秒传需要依赖上一步生成的 hash,即在<code>上传前</code>,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可</p><pre><code>+ async verifyUpload(filename, fileHash) &#123;+ const &#123; data &#125; = await this.request(&#123;+ url: &quot;http://localhost:3000/verify&quot;,+ headers: &#123;+ &quot;content-type&quot;: &quot;application/json&quot;+ &#125;,+ data: JSON.stringify(&#123;+ filename,+ fileHash+ &#125;)+ &#125;);+ return JSON.parse(data);+ &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList);+ const &#123; shouldUpload &#125; = await this.verifyUpload(+ this.container.file.name,+ this.container.hash+ );+ if (!shouldUpload) &#123;+ this.$message.success(&quot;秒传:上传成功&quot;);+ return;+ &#125; this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123; fileHash: this.container.hash, index, hash: this.container.hash + &quot;-&quot; + index, chunk: file, percentage: 0 &#125;)); await this.uploadChunks(); &#125; 复制代码</code></pre><p>秒传其实就是给用户看的障眼法,实质上根本没有上传<br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f890e5967106c6?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="image-20200109143511277"><br>:)</p><p>服务端的逻辑非常简单,新增一个验证接口,验证文件是否存在即可</p><pre><code>+ const extractExt = filename =&gt;+ filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录const resolvePost = req =&gt; new Promise(resolve =&gt; &#123; let chunk = &quot;&quot;; req.on(&quot;data&quot;, data =&gt; &#123; chunk += data; &#125;); req.on(&quot;end&quot;, () =&gt; &#123; resolve(JSON.parse(chunk)); &#125;); &#125;);server.on(&quot;request&quot;, async (req, res) =&gt; &#123; if (req.url === &quot;/verify&quot;) &#123;+ const data = await resolvePost(req);+ const &#123; fileHash, filename &#125; = data;+ const ext = extractExt(filename);+ const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;$&#123;ext&#125;`;+ if (fse.existsSync(filePath)) &#123;+ res.end(+ JSON.stringify(&#123;+ shouldUpload: false+ &#125;)+ );+ &#125; else &#123;+ res.end(+ JSON.stringify(&#123;+ shouldUpload: true+ &#125;)+ );+ &#125; &#125;&#125;);server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));复制代码</code></pre><h2 id="暂停上传"><a href="#暂停上传" class="headerlink" title="暂停上传"></a>暂停上传</h2><p>讲完了生成 hash 和文件秒传,回到断点续传</p><p>断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传</p><p>原理是使用 XMLHttpRequest 的 <code>abort</code> 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来,我们再改造一下 request 方法</p><pre><code> request(&#123; url, method = &quot;post&quot;, data, headers = &#123;&#125;, onProgress = e =&gt; e,+ requestList &#125;) &#123; return new Promise(resolve =&gt; &#123; const xhr = new XMLHttpRequest(); xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key =&gt; xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e =&gt; &#123;+ // 将请求成功的 xhr 从列表中删除+ if (requestList) &#123;+ const xhrIndex = requestList.findIndex(item =&gt; item === xhr);+ requestList.splice(xhrIndex, 1);+ &#125; resolve(&#123; data: e.target.response &#125;); &#125;;+ // 暴露当前 xhr 给外部+ requestList?.push(xhr); &#125;); &#125;,复制代码</code></pre><p>这样在上传切片时传入 requestList 数组作为参数,request 方法就会将所有的 xhr 保存在数组中了<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f8563491ea7793?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存<code>正在上传切片的 xhr</code></p><p>之后新建一个暂停按钮,当点击按钮时,调用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上传的切片</p><pre><code> handlePause() &#123; this.requestList.forEach(xhr =&gt; xhr?.abort()); this.requestList = [];&#125;复制代码</code></pre><p><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f890e598dd6afb?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="image-20200109143737924"><br>点击暂停按钮可以看到 xhr 都被取消了<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f85644dc27459e?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h2 id="恢复上传"><a href="#恢复上传" class="headerlink" title="恢复上传"></a>恢复上传</h2><p>之前在介绍断点续传的时提到使用第二种服务端存储的方式实现续传</p><p>由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果</p><p>而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果</p><ul><li>服务端已存在该文件,不需要再次上传</li><li>服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把<strong>已上传</strong>的文件切片返回给前端</li></ul><p>所以我们改造一下之前文件秒传的服务端验证接口</p><pre><code>const extractExt = filename =&gt; filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录const resolvePost = req =&gt; new Promise(resolve =&gt; &#123; let chunk = &quot;&quot;; req.on(&quot;data&quot;, data =&gt; &#123; chunk += data; &#125;); req.on(&quot;end&quot;, () =&gt; &#123; resolve(JSON.parse(chunk)); &#125;); &#125;); + // 返回已经上传切片名列表+ const createUploadedList = async fileHash =&gt;+ fse.existsSync(`$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;`)+ ? await fse.readdir(`$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;`)+ : [];server.on(&quot;request&quot;, async (req, res) =&gt; &#123; if (req.url === &quot;/verify&quot;) &#123; const data = await resolvePost(req); const &#123; fileHash, filename &#125; = data; const ext = extractExt(filename); const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;$&#123;ext&#125;`; if (fse.existsSync(filePath)) &#123; res.end( JSON.stringify(&#123; shouldUpload: false &#125;) ); &#125; else &#123; res.end( JSON.stringify(&#123; shouldUpload: true,+ uploadedList: await createUploadedList(fileHash) &#125;) ); &#125; &#125;&#125;);server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));复制代码</code></pre><p>接着回到前端,前端有两个地方需要调用验证的接口</p><ul><li>点击上传时,检查是否需要上传和已上传的切片</li><li>点击暂停后的恢复上传,返回已上传的切片</li></ul><p>新增恢复按钮并改造原来上传切片的逻辑</p><pre><code>&lt;template&gt; &lt;div id=&quot;app&quot;&gt; &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt; &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt; &lt;el-button @click=&quot;handlePause&quot; v-if=&quot;isPaused&quot;&gt;暂停&lt;/el-button&gt;+ &lt;el-button @click=&quot;handleResume&quot; v-else&gt;恢复&lt;/el-button&gt; //... &lt;/div&gt;&lt;/template&gt;+ async handleResume() &#123;+ const &#123; uploadedList &#125; = await this.verifyUpload(+ this.container.file.name,+ this.container.hash+ );+ await this.uploadChunks(uploadedList); &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList);+ const &#123; shouldUpload, uploadedList &#125; = await this.verifyUpload( this.container.file.name, this.container.hash ); if (!shouldUpload) &#123; this.$message.success(&quot;秒传:上传成功&quot;); return; &#125; this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123; fileHash: this.container.hash, index, hash: this.container.hash + &quot;-&quot; + index, chunk: file, percentage: 0 &#125;));+ await this.uploadChunks(uploadedList); &#125;, // 上传切片,同时过滤已上传的切片+ async uploadChunks(uploadedList = []) &#123; const requestList = this.data+ .filter((&#123; hash &#125;) =&gt; !uploadedList.includes(hash)) .map((&#123; chunk, hash, index &#125;) =&gt; &#123; const formData = new FormData(); formData.append(&quot;chunk&quot;, chunk); formData.append(&quot;hash&quot;, hash); formData.append(&quot;filename&quot;, this.container.file.name); formData.append(&quot;fileHash&quot;, this.container.hash); return &#123; formData, index &#125;; &#125;) .map(async (&#123; formData, index &#125;) =&gt; this.request(&#123; url: &quot;http://localhost:3000&quot;, data: formData, onProgress: this.createProgressHandler(this.data[index]), requestList: this.requestList &#125;) ); await Promise.all(requestList); // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时 // 合并切片+ if (uploadedList.length + requestList.length === this.data.length) &#123; await this.mergeRequest();+ &#125; &#125;复制代码</code></pre><p>![image-20200109144331326](data:image&#x2F;svg+xml;utf8,<?xml version=&quot;1.0&quot;?><svg xmlns=&quot;http://www.w3.org/2000/svg&quot; version=&quot;1.1&quot; width=&quot;463&quot; height=&quot;54&quot;></svg>)<br>这里给原来上传切片的函数新增 uploadedList 参数,即上图中服务端返回的切片名列表,通过 filter 过滤掉已上传的切片,并且由于新增了已上传的部分,所以之前合并接口的触发条件做了一些改动</p><p>到这里断点续传的功能基本完成了</p><h2 id="进度条改进"><a href="#进度条改进" class="headerlink" title="进度条改进"></a>进度条改进</h2><p>虽然实现了断点续传,但还需要修改一下进度条的显示规则,否则在暂停上传&#x2F;接收到已上传切片时的进度条会出现偏差</p><h3 id="切片进度条-1"><a href="#切片进度条-1" class="headerlink" title="切片进度条"></a>切片进度条</h3><p>由于在点击上传&#x2F;恢复上传时,会调用验证接口返回已上传的切片,所以需要将已上传切片的进度变成 100%</p><pre><code> async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList); const &#123; shouldUpload, uploadedList &#125; = await this.verifyUpload( this.container.file.name, this.container.hash ); if (!shouldUpload) &#123; this.$message.success(&quot;秒传:上传成功&quot;); return; &#125; this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123; fileHash: this.container.hash, index, hash: this.container.hash + &quot;-&quot; + index, chunk: file,+ percentage: uploadedList.includes(index) ? 100 : 0 &#125;)); await this.uploadChunks(uploadedList); &#125;,复制代码</code></pre><p>uploadedList 会返回已上传的切片,在遍历所有切片时判断当前切片是否在已上传列表里即可</p><h3 id="文件进度条-1"><a href="#文件进度条-1" class="headerlink" title="文件进度条"></a>文件进度条</h3><p>之前说到文件进度条是一个计算属性,根据所有切片的上传进度计算而来,这就遇到了一个问题<br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f8845dcca1427c?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>点击暂停会取消并清空切片的 xhr 请求,此时如果已经上传了一部分,就会发现文件进度条有<code>倒退</code>的现象<br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f884633f3acfd9?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>当点击恢复时,由于重新创建了 xhr 导致切片进度清零,所以总进度条就会倒退</p><p>解决方案是创建一个“假”的进度条,这个假进度条基于文件进度条,但只会停止和增加,然后给用户展示这个假的进度条</p><p>这里我们使用 Vue 的监听属性</p><pre><code> data: () =&gt; (&#123;+ fakeUploadPercentage: 0 &#125;), computed: &#123; uploadPercentage() &#123; if (!this.container.file || !this.data.length) return 0; const loaded = this.data .map(item =&gt; item.size * item.percentage) .reduce((acc, cur) =&gt; acc + cur); return parseInt((loaded / this.container.file.size).toFixed(2)); &#125; &#125;, watch: &#123;+ uploadPercentage(now) &#123;+ if (now &gt; this.fakeUploadPercentage) &#123;+ this.fakeUploadPercentage = now;+ &#125; &#125; &#125;,复制代码</code></pre><p>当 uploadPercentage 即真的文件进度条增加时,fakeUploadPercentage 也增加,一旦文件进度条后退,假的进度条只需停止即可</p><p>至此一个大文件上传 + 断点续传的解决方案就完成了</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>大文件上传</p><ul><li>前端上传大文件时使用 Blob.prototype.slice 将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片</li><li>服务端接收切片并存储,收到合并请求后使用 fs.appendFileSync 对多个切片进行合并</li><li>原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听</li><li>使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度</li></ul><p>断点续传</p><ul><li>使用 spark-md5 根据文件内容算出文件 hash</li><li>通过 hash 可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)</li><li>通过 XMLHttpRequest 的 abort 方法暂停切片的上传</li><li>上传前服务端返回已经上传的切片名,前端跳过这些切片的上传</li></ul><h1 id="源代码"><a href="#源代码" class="headerlink" title="源代码"></a>源代码</h1><p>源代码增加了一些按钮的状态,交互更加友好,文章表达比较晦涩的地方可以跳转到源代码查看</p><p><a href="https://github.com/yeyan1996/file-upload">file-upload</a><br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f8859bdc09fa25?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>谢谢观看 :)</p><h1 id="字节跳动-EA(Enterprise-Application)前端团队招人啦~"><a href="#字节跳动-EA(Enterprise-Application)前端团队招人啦~" class="headerlink" title="字节跳动 EA(Enterprise Application)前端团队招人啦~"></a>字节跳动 EA(Enterprise Application)前端团队招人啦~</h1><p>坐标上海&#x2F;北京,校招社招都有,hc 无上限,有兴趣的欢迎投递简历至 <a href="mailto:&#49;&#57;&#x39;&#54;&#121;&#x65;&#121;&#97;&#110;&#x40;&#x67;&#109;&#97;&#105;&#108;&#x2e;&#99;&#111;&#x6d;">&#49;&#57;&#x39;&#54;&#121;&#x65;&#121;&#97;&#110;&#x40;&#x67;&#109;&#97;&#105;&#108;&#x2e;&#99;&#111;&#x6d;</a>,校招内推码 Q7QUGMV</p><h1 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h1><p><a href="https://juejin.im/post/5da14778f265da5bb628e590">写给新手前端的各种文件上传攻略,从小图片到大文件断点续传</a></p><p><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/slice">Blob.slice</a><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/slice">developer.mozilla.org&#x2F;zh-CN&#x2F;docs&#x2F;…</a>)</p>]]></content>
2538
+ <content type="html"><![CDATA[<p><code>原文地址:https://juejin.im/post/5dff8a26e51d4558105420ed</code><br>这段时间面试官都挺忙的,频频出现在博客文章标题,虽然我不是特别想蹭热度,但是实在想不到好的标题了-。-,蹭蹭就蹭蹭 :)<br>事实上我在面试的时候确实被问到了这个问题,而且是一道在线 coding 的编程题,当时虽然思路正确,可惜最终也并不算完全答对<br>结束后花了一段时间整理了下思路,那么究竟该如何实现一个大文件上传,以及在上传中如何实现断点续传的功能呢?<br>本文将从零搭建前端和服务端,实现一个大文件上传和断点续传的 demo</p><span id="more"></span><p>前端:<code>vue``element-ui</code><br>服务端:<code>nodejs</code></p><p><code>文章有误解的地方,欢迎指出,将在第一时间改正,有更好的实现方式希望留下你的评论</code></p><h1 id="大文件上传"><a href="#大文件上传" class="headerlink" title="大文件上传"></a>大文件上传</h1><h2 id="整体思路"><a href="#整体思路" class="headerlink" title="整体思路"></a>整体思路</h2><h3 id="前端"><a href="#前端" class="headerlink" title="前端"></a>前端</h3><p>前端大文件上传网上的大部分文章已经给出了解决方案,核心是<code>利用 Blob.prototype.slice</code> 方法,和数组的 slice 方法相似,调用的 slice 方法可以返回<code>原文件的某个切片</code></p><p>这样我们就可以根据预先设置好的切片最大数量将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片,这样从原本传一个大文件,变成了<code>同时</code>传多个小的文件切片,可以大大减少上传时间</p><p>另外由于是并发,传输到服务端的顺序可能会发生变化,所以我们还需要给每个切片记录顺序</p><h3 id="服务端"><a href="#服务端" class="headerlink" title="服务端"></a>服务端</h3><p>服务端需要负责接受这些切片,并在接收到所有切片后<code>合并</code>切片</p><p>这里又引伸出两个问题</p><ol><li>何时合并切片,即切片什么时候传输完成</li><li>如何合并切片</li></ol><p>第一个问题需要前端进行配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并,也可以额外发一个请求主动通知服务端进行切片的合并</p><p>第二个问题,具体如何合并切片呢?这里可以使用 nodejs 的 api <code>fs.appendFileSync</code>,它可以同步地将数据追加到指定文件,也就是说,当服务端接受到所有切片后,先创建一个最终的文件,然后将所有切片逐步合并到这个文件中</p><p><code>talk is cheap,show me the code</code>,接着我们用代码实现上面的思路</p><h2 id="前端部分"><a href="#前端部分" class="headerlink" title="前端部分"></a>前端部分</h2><p>前端使用 Vue 作为开发框架,对界面没有太大要求,原生也可以,考虑到美观使用 element-ui 作为 UI 框架</p><h3 id="上传控件"><a href="#上传控件" class="headerlink" title="上传控件"></a>上传控件</h3><p>首先创建选择文件的控件,监听 change 事件以及上传按钮</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line"> &lt;div&gt;</span><br><span class="line"> &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line"> &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line"> &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">export default &#123;</span><br><span class="line"> data: () =&gt; (&#123;</span><br><span class="line"> container: &#123;</span><br><span class="line"> file: null</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;),</span><br><span class="line"> methods: &#123;</span><br><span class="line"> handleFileChange(e) &#123;</span><br><span class="line"> const [file] = e.target.files;</span><br><span class="line"> if (!file) return;</span><br><span class="line"> Object.assign(this.$data, this.$options.data());</span><br><span class="line"> this.container.file = file;</span><br><span class="line"> &#125;,</span><br><span class="line"> async handleUpload() &#123;&#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p><img src="https://user-gold-cdn.xitu.io/2020/1/7/16f7ff98adb6bcdd?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h3 id="请求逻辑"><a href="#请求逻辑" class="headerlink" title="请求逻辑"></a>请求逻辑</h3><p>考虑到通用性,这里没有用第三方的请求库,而是用原生 XMLHttpRequest 做一层简单的封装来发请求</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">request(&#123;</span><br><span class="line"> url,</span><br><span class="line"> method = &quot;post&quot;,</span><br><span class="line"> data,</span><br><span class="line"> headers = &#123;&#125;,</span><br><span class="line"> requestList</span><br><span class="line"> &#125;) &#123;</span><br><span class="line"> returnnewPromise(resolve =&gt; &#123;</span><br><span class="line"> const xhr = new XMLHttpRequest();</span><br><span class="line"> xhr.open(method, url);</span><br><span class="line"> Object.keys(headers).forEach(key =&gt;</span><br><span class="line"> xhr.setRequestHeader(key, headers[key])</span><br><span class="line"> );</span><br><span class="line"> xhr.send(data);</span><br><span class="line"> xhr.onload = e =&gt; &#123;</span><br><span class="line"> resolve(&#123;</span><br><span class="line"> data: e.target.response</span><br><span class="line"> &#125;);</span><br><span class="line"> &#125;;</span><br><span class="line"> &#125;);</span><br><span class="line"> &#125;</span><br></pre></td></tr></table></figure><h3 id="上传切片"><a href="#上传切片" class="headerlink" title="上传切片"></a>上传切片</h3><p>接着实现比较重要的上传功能,上传需要做两件事</p><ul><li>对文件进行切片</li><li>将切片传输给服务端</li></ul><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line"> &lt;div&gt;</span><br><span class="line"> &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line"> &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line"> &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">+ const LENGTH = 10; // 切片数量</span><br><span class="line"></span><br><span class="line">export default &#123;</span><br><span class="line"> data: () =&gt; (&#123;</span><br><span class="line"> container: &#123;</span><br><span class="line"> file: null,</span><br><span class="line">+ data: []</span><br><span class="line"> &#125;</span><br><span class="line"> &#125;),</span><br><span class="line"> methods: &#123;</span><br><span class="line"> request() &#123;&#125;,</span><br><span class="line"> handleFileChange() &#123;&#125;,</span><br><span class="line">+ // 生成文件切片</span><br><span class="line">+ createFileChunk(file, length = LENGTH) &#123;</span><br><span class="line">+ const fileChunkList = [];</span><br><span class="line">+ const chunkSize = Math.ceil(file.size / length);</span><br><span class="line">+ let cur = 0;</span><br><span class="line">+ while (cur &lt; file.size) &#123;</span><br><span class="line">+ fileChunkList.push(&#123; file: file.slice(cur, cur + chunkSize) &#125;);</span><br><span class="line">+ cur += chunkSize;+ &#125;</span><br><span class="line">+ return fileChunkList;+ &#125;,</span><br><span class="line">+ // 上传切片</span><br><span class="line">+ async uploadChunks() &#123;</span><br><span class="line">+ const requestList = this.data</span><br><span class="line">+ .map((&#123; chunk,hash &#125;) =&gt; &#123;</span><br><span class="line">+ const formData = new FormData();</span><br><span class="line">+ formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line">+ formData.append(&quot;hash&quot;, hash);</span><br><span class="line">+ formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line">+ return &#123; formData &#125;;</span><br><span class="line">+ &#125;)</span><br><span class="line">+ .map(async (&#123; formData &#125;) =&gt;</span><br><span class="line">+ this.request(&#123;</span><br><span class="line">+ url: &quot;http://localhost:3000&quot;,</span><br><span class="line">+ data: formData+ &#125;)</span><br><span class="line">+ );</span><br><span class="line">+ await Promise.all(requestList); // 并发切片</span><br><span class="line">+ &#125;,</span><br><span class="line">+ async handleUpload() &#123;</span><br><span class="line">+ if (!this.container.file) return;</span><br><span class="line">+ const fileChunkList = this.createFileChunk(this.container.file);</span><br><span class="line">+ this.data = fileChunkList.map((&#123; file &#125;,index) =&gt; (&#123;</span><br><span class="line">+ chunk: file,</span><br><span class="line">+ hash: this.container.file.name + &quot;-&quot; + index // 文件名 + 数组下标</span><br><span class="line">+ &#125;));</span><br><span class="line">+ await this.uploadChunks();</span><br><span class="line">+ &#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;</span><br></pre></td></tr></table></figure><p>当点击上传按钮时,调用 <code>createFileChunk</code> 将文件切片,切片数量通过一个常量 Length 控制,这里设置为 10,即将文件分成 10 个切片上传</p><p>createFileChunk 内使用 while 循环和 slice 方法将切片放入 <code>fileChunkList</code> 数组中返回</p><p>在生成文件切片时,需要给每个切片一个标识作为 hash,这里暂时使用<code>文件名 + 下标</code>,这样后端可以知道当前切片是第几个切片,用于之后的合并切片</p><p>随后调用 <code>uploadChunks</code> 上传所有的文件切片,将文件切片,切片 hash,以及文件名放入 FormData 中,再调用上一步的 <code>request</code> 函数返回一个 proimise,最后调用 Promise.all 并发上传所有的切片</p><h3 id="发送合并请求"><a href="#发送合并请求" class="headerlink" title="发送合并请求"></a>发送合并请求</h3><p>这里使用整体思路中提到的第二种合并切片的方式,即前端主动通知服务端进行合并,所以前端还需要额外发请求,服务端接受到这个请求时主动合并切片</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br></pre></td><td class="code"><pre><span class="line">&lt;template&gt;</span><br><span class="line"> &lt;div&gt;</span><br><span class="line"> &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt;</span><br><span class="line"> &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt;</span><br><span class="line"> &lt;/div&gt;</span><br><span class="line">&lt;/template&gt;</span><br><span class="line"></span><br><span class="line">&lt;script&gt;</span><br><span class="line">export default &#123;</span><br><span class="line"> data: () =&gt; (&#123;</span><br><span class="line"> container: &#123;</span><br><span class="line"> file: null</span><br><span class="line"> &#125;,</span><br><span class="line"> data: []</span><br><span class="line"> &#125;),</span><br><span class="line"> methods: &#123;</span><br><span class="line"> request() &#123;&#125;,</span><br><span class="line"> handleFileChange() &#123;&#125;,</span><br><span class="line"> createFileChunk() &#123;&#125;,</span><br><span class="line"> // 上传切片,同时过滤已上传的切片</span><br><span class="line"> async uploadChunks() &#123;</span><br><span class="line"> const requestList = this.data</span><br><span class="line"> .map((&#123; chunk,hash &#125;) =&gt; &#123;</span><br><span class="line"> const formData = new FormData();</span><br><span class="line"> formData.append(&quot;chunk&quot;, chunk);</span><br><span class="line"> formData.append(&quot;hash&quot;, hash);</span><br><span class="line"> formData.append(&quot;filename&quot;, this.container.file.name);</span><br><span class="line"> return &#123; formData &#125;;</span><br><span class="line"> &#125;)</span><br><span class="line"> .map(async (&#123; formData &#125;) =&gt;</span><br><span class="line"> this.request(&#123;</span><br><span class="line"> url: &quot;http://localhost:3000&quot;,</span><br><span class="line"> data: formData</span><br><span class="line"> &#125;)</span><br><span class="line"> );</span><br><span class="line"> await Promise.all(requestList);</span><br><span class="line">+ // 合并切片+ await this.mergeRequest();</span><br><span class="line"> &#125;,</span><br><span class="line">+ async mergeRequest() &#123;</span><br><span class="line">+ await this.request(&#123;</span><br><span class="line">+ url: &quot;http://localhost:3000/merge&quot;,</span><br><span class="line">+ headers: &#123;</span><br><span class="line">+ &quot;content-type&quot;: &quot;application/json&quot;</span><br><span class="line">+ &#125;,</span><br><span class="line">+ data: JSON.stringify(&#123;</span><br><span class="line">+ filename: this.container.file.name+ &#125;)</span><br><span class="line">+ &#125;);</span><br><span class="line">+ &#125;,</span><br><span class="line"> async handleUpload() &#123;&#125;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;;</span><br><span class="line">&lt;/script&gt;</span><br></pre></td></tr></table></figure><h2 id="服务端部分"><a href="#服务端部分" class="headerlink" title="服务端部分"></a>服务端部分</h2><p>简单使用 http 模块搭建服务端</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">const http = require(&quot;http&quot;);</span><br><span class="line">const server = http.createServer();</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);</span><br><span class="line"> if (req.method === &quot;OPTIONS&quot;) &#123;</span><br><span class="line"> res.status = 200;</span><br><span class="line"> res.end();</span><br><span class="line"> return;</span><br><span class="line"> &#125;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure><h3 id="接受切片"><a href="#接受切片" class="headerlink" title="接受切片"></a>接受切片</h3><p>使用 <code>multiparty</code> 包处理前端传来的 FormData</p><p>在 multiparty.parse 的回调中,files 参数保存了 FormData 中文件,fields 参数保存了 FormData 中非文件的字段</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line">const http = require(&quot;http&quot;);</span><br><span class="line">const path = require(&quot;path&quot;);</span><br><span class="line">const fse = require(&quot;fs-extra&quot;);</span><br><span class="line">const multiparty = require(&quot;multiparty&quot;);</span><br><span class="line"></span><br><span class="line">const server = http.createServer();</span><br><span class="line">+ const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录</span><br><span class="line"></span><br><span class="line">server.on(&quot;request&quot;, async (req, res) =&gt; &#123;</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);</span><br><span class="line"> res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;);</span><br><span class="line"> if (req.method === &quot;OPTIONS&quot;) &#123;</span><br><span class="line"> res.status = 200;</span><br><span class="line"> res.end();</span><br><span class="line"> return;</span><br><span class="line"> &#125;</span><br><span class="line"></span><br><span class="line">+ const multipart = new multiparty.Form();</span><br><span class="line">+ multipart.parse(req, async (err, fields, files) =&gt; &#123;</span><br><span class="line">+ if (err) &#123;</span><br><span class="line">+ return;</span><br><span class="line">+ &#125;</span><br><span class="line">+ const [chunk] = files.chunk;</span><br><span class="line">+ const [hash] = fields.hash;</span><br><span class="line">+ const [filename] = fields.filename;</span><br><span class="line">+ const chunkDir = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;</span><br><span class="line">+ // 切片目录不存在,创建切片目录</span><br><span class="line">+ if (!fse.existsSync(chunkDir)) &#123;</span><br><span class="line">+ await fse.mkdirs(chunkDir);</span><br><span class="line">+ &#125;</span><br><span class="line">+ // fs-extra 专用方法,类似 fs.rename 并且跨平台</span><br><span class="line">+ // fs-extra 的 rename 方法 windows 平台会有权限问题</span><br><span class="line">+ // https://github.com/meteor/meteor/issues/7852#issuecomment-255767835</span><br><span class="line">+ await fse.move(chunk.path, `$&#123;chunkDir&#125;/$&#123;hash&#125;`);</span><br><span class="line">+ res.end(&quot;received file chunk&quot;);</span><br><span class="line">+ &#125;);</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line">server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));</span><br></pre></td></tr></table></figure><p><img src="https://user-gold-cdn.xitu.io/2020/1/11/16f905fb6a626f47?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="image-20200110215559194"><br>查看 multiparty 处理后的 chunk 对象,path 是存储临时文件的路径,size 是临时文件大小,在 multiparty 文档中提到可以使用 fs.rename(由于我用的是 fs-extra,它的 rename 方法 windows 平台权限问题,所以换成了 fse.move) 移动临时文件,即移动文件切片</p><p>在接受文件切片时,需要先创建存储切片的文件夹,由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片从临时路径移动切片文件夹中,最后的结果如下<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f83c3caac8eb65?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h3 id="合并切片"><a href="#合并切片" class="headerlink" title="合并切片"></a>合并切片</h3><p>在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并</p><pre><code>const http = require(&quot;http&quot;);const path = require(&quot;path&quot;);const fse = require(&quot;fs-extra&quot;);const server = http.createServer();const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录+ const resolvePost = req =&gt;+ new Promise(resolve =&gt; &#123;+ let chunk = &quot;&quot;;+ req.on(&quot;data&quot;, data =&gt; &#123;+ chunk += data;+ &#125;);+ req.on(&quot;end&quot;, () =&gt; &#123;+ resolve(JSON.parse(chunk));+ &#125;);+ &#125;);+ // 合并切片+ const mergeFileChunk = async (filePath, filename) =&gt; &#123;+ const chunkDir = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;+ const chunkPaths = await fse.readdir(chunkDir);+ await fse.writeFile(filePath, &quot;&quot;);+ chunkPaths.forEach(chunkPath =&gt; &#123;+ fse.appendFileSync(filePath, fse.readFileSync(`$&#123;chunkDir&#125;/$&#123;chunkPath&#125;`));+ fse.unlinkSync(`$&#123;chunkDir&#125;/$&#123;chunkPath&#125;`);+ &#125;);+ fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录+ &#125;;server.on(&quot;request&quot;, async (req, res) =&gt; &#123; res.setHeader(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;); res.setHeader(&quot;Access-Control-Allow-Headers&quot;, &quot;*&quot;); if (req.method === &quot;OPTIONS&quot;) &#123; res.status = 200; res.end(); return; &#125;+ if (req.url === &quot;/merge&quot;) &#123;+ const data = await resolvePost(req);+ const &#123; filename &#125; = data;+ const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;filename&#125;`;+ await mergeFileChunk(filePath, filename);+ res.end(+ JSON.stringify(&#123;+ code: 0,+ message: &quot;file merged success&quot;+ &#125;)+ );+ &#125;&#125;);server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));复制代码</code></pre><p>由于前端在发送合并请求时会携带文件名,服务端根据文件名可以找到上一步创建的切片文件夹</p><p>接着使用 fs.writeFileSync 先创建一个空文件,这个空文件的文件名就是<strong>切片文件夹名 + 后缀名</strong>组合而成,随后通过 fs.appendFileSync 从切片文件夹中不断将切片合并到空文件中,每次合并完成后删除这个切片,等所有切片都合并完毕后最后删除切片文件夹<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f8414c7fae1c1f?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>至此一个简单的大文件上传就完成了,接下来我们再此基础上扩展一些额外的功能</p><h2 id="显示上传进度条"><a href="#显示上传进度条" class="headerlink" title="显示上传进度条"></a>显示上传进度条</h2><p>上传进度分两种,一个是每个切片的上传进度,另一个是整个文件的上传进度,而整个文件的上传进度是基于每个切片上传进度计算而来,所以我们先实现切片的上传进度</p><h3 id="切片进度条"><a href="#切片进度条" class="headerlink" title="切片进度条"></a>切片进度条</h3><p>XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,我们在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件</p><pre><code> // xhr request(&#123; url, method = &quot;post&quot;, data, headers = &#123;&#125;,+ onProgress = e =&gt; e, requestList &#125;) &#123; return new Promise(resolve =&gt; &#123; const xhr = new XMLHttpRequest();+ xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key =&gt; xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e =&gt; &#123; resolve(&#123; data: e.target.response &#125;); &#125;; &#125;); &#125;复制代码</code></pre><p>由于每个切片都需要触发独立的监听事件,所以还需要一个工厂函数,根据传入的切片返回不同的监听函数</p><p>在原先的前端上传逻辑中新增监听函数部分</p><pre><code> // 上传切片,同时过滤已上传的切片 async uploadChunks(uploadedList = []) &#123; const requestList = this.data+ .map((&#123; chunk,hash,index &#125;) =&gt; &#123; const formData = new FormData(); formData.append(&quot;chunk&quot;, chunk); formData.append(&quot;hash&quot;, hash); formData.append(&quot;filename&quot;, this.container.file.name);+ return &#123; formData,index &#125;; &#125;)+ .map(async (&#123; formData,index &#125;) =&gt; this.request(&#123; url: &quot;http://localhost:3000&quot;, data: formData,+ onProgress: this.createProgressHandler(this.data[index]), &#125;) ); await Promise.all(requestList); // 合并切片 await this.mergeRequest(); &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.data = fileChunkList.map((&#123; file &#125;,index) =&gt; (&#123; chunk: file,+ index, hash: this.container.file.name + &quot;-&quot; + index+ percentage:0 &#125;)); await this.uploadChunks(); &#125; + createProgressHandler(item) &#123;+ return e =&gt; &#123;+ item.percentage = parseInt(String((e.loaded / e.total) * 100));+ &#125;;+ &#125;复制代码</code></pre><p>每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可</p><h3 id="文件进度条"><a href="#文件进度条" class="headerlink" title="文件进度条"></a>文件进度条</h3><p>将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 计算属性</p><pre><code> computed: &#123; uploadPercentage() &#123; if (!this.container.file || !this.data.length) return0; const loaded = this.data .map(item =&gt; item.size * item.percentage) .reduce((acc, cur) =&gt; acc + cur); returnparseInt((loaded / this.container.file.size).toFixed(2)); &#125; &#125;复制代码</code></pre><p>最终视图如下<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84ad6b4848423?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h1 id="断点续传"><a href="#断点续传" class="headerlink" title="断点续传"></a>断点续传</h1><p>断点续传的原理在于前端&#x2F;服务端需要<code>记住</code>已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能</p><ul><li>前端使用 localStorage 记录已上传的切片 hash</li><li>服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片</li></ul><p>第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选取后者</p><h2 id="生成-hash"><a href="#生成-hash" class="headerlink" title="生成 hash"></a>生成 hash</h2><p>无论是前端还是服务端,都必须要生成文件和切片的 hash,<code>之前我们使用文件名 + 切片下标作为切片 hash</code>,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是<code>根据文件内容生成 hash</code>,所以我们修改一下 hash 的生成规则</p><p>这里用到另一个库 <code>spark-md5</code>,它可以根据文件内容计算出文件的 hash 值,另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会<code>引起 UI 的阻塞</code>,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互</p><p>由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了<code>importScripts</code> 函数用于导入外部脚本,通过它导入 spark-md5</p><pre><code>// /public/hash.jsself.importScripts(&quot;/spark-md5.min.js&quot;); // 导入脚本// 生成文件 hashself.onmessage = e =&gt; &#123; const &#123; fileChunkList &#125; = e.data; const spark = new self.SparkMD5.ArrayBuffer(); let percentage = 0; let count = 0; const loadNext = index =&gt; &#123; const reader = new FileReader(); reader.readAsArrayBuffer(fileChunkList[index].file); reader.onload = e =&gt; &#123; count++; spark.append(e.target.result); if (count === fileChunkList.length) &#123; self.postMessage(&#123; percentage: 100, hash: spark.end() &#125;); self.close(); &#125; else &#123; percentage += 100 / fileChunkList.length; self.postMessage(&#123; percentage &#125;); // 递归计算下一个切片 loadNext(count); &#125; &#125;; &#125;; loadNext(0);&#125;;复制代码</code></pre><p>在 worker 线程中,接受文件切片 fileChunkList,利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程</p><p><code>spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash,具体可以看官方文档</code></p><p><a href="https://www.npmjs.com/package/spark-md5">spark-md5</a></p><p>接着编写主线程与 worker 线程通讯的逻辑</p><pre><code>+ // 生成文件 hash(web-worker)+ calculateHash(fileChunkList) &#123;+ return new Promise(resolve =&gt; &#123;+ // 添加 worker 属性+ this.container.worker = new Worker(&quot;/hash.js&quot;);+ this.container.worker.postMessage(&#123; fileChunkList &#125;);+ this.container.worker.onmessage = e =&gt; &#123;+ const &#123; percentage, hash &#125; = e.data;+ this.hashPercentage = percentage;+ if (hash) &#123;+ resolve(hash);+ &#125;+ &#125;;+ &#125;); &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file);+ this.container.hash = await this.calculateHash(fileChunkList); this.data = fileChunkList.map((&#123; file &#125;,index) =&gt; (&#123;+ fileHash: this.container.hash, chunk: file, hash: this.container.file.name + &quot;-&quot; + index, // 文件名 + 数组下标 percentage:0 &#125;)); await this.uploadChunks(); &#125; 复制代码</code></pre><p>主线程使用 <code>postMessage</code> 给 worker 线程传入所有切片 fileChunkList,并监听 worker 线程发出的 postMessage 事件拿到文件 hash</p><p>加上显示计算 hash 的进度条,看起来像这样<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84ae4f233e891?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>至此前端需要将之前用文件名作为 hash 的地方改写为 workder 返回的这个 hash<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84b3bba953304?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>服务端则使用 hash 作为切片文件夹名,hash + 下标作为切片名,hash + 扩展名作为文件名,没有新增的逻辑<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84b50117e4db2?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f84b33bbceaa48?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h2 id="文件秒传"><a href="#文件秒传" class="headerlink" title="文件秒传"></a>文件秒传</h2><p>在实现断点续传前先简单介绍一下文件秒传</p><p>所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户<code>再次上传</code>时会直接提示上传成功</p><p>文件秒传需要依赖上一步生成的 hash,即在<code>上传前</code>,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可</p><pre><code>+ async verifyUpload(filename, fileHash) &#123;+ const &#123; data &#125; = await this.request(&#123;+ url: &quot;http://localhost:3000/verify&quot;,+ headers: &#123;+ &quot;content-type&quot;: &quot;application/json&quot;+ &#125;,+ data: JSON.stringify(&#123;+ filename,+ fileHash+ &#125;)+ &#125;);+ return JSON.parse(data);+ &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList);+ const &#123; shouldUpload &#125; = await this.verifyUpload(+ this.container.file.name,+ this.container.hash+ );+ if (!shouldUpload) &#123;+ this.$message.success(&quot;秒传:上传成功&quot;);+ return;+ &#125; this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123; fileHash: this.container.hash, index, hash: this.container.hash + &quot;-&quot; + index, chunk: file, percentage: 0 &#125;)); await this.uploadChunks(); &#125; 复制代码</code></pre><p>秒传其实就是给用户看的障眼法,实质上根本没有上传<br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f890e5967106c6?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="image-20200109143511277"><br>:)</p><p>服务端的逻辑非常简单,新增一个验证接口,验证文件是否存在即可</p><pre><code>+ const extractExt = filename =&gt;+ filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录const resolvePost = req =&gt; new Promise(resolve =&gt; &#123; let chunk = &quot;&quot;; req.on(&quot;data&quot;, data =&gt; &#123; chunk += data; &#125;); req.on(&quot;end&quot;, () =&gt; &#123; resolve(JSON.parse(chunk)); &#125;); &#125;);server.on(&quot;request&quot;, async (req, res) =&gt; &#123; if (req.url === &quot;/verify&quot;) &#123;+ const data = await resolvePost(req);+ const &#123; fileHash, filename &#125; = data;+ const ext = extractExt(filename);+ const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;$&#123;ext&#125;`;+ if (fse.existsSync(filePath)) &#123;+ res.end(+ JSON.stringify(&#123;+ shouldUpload: false+ &#125;)+ );+ &#125; else &#123;+ res.end(+ JSON.stringify(&#123;+ shouldUpload: true+ &#125;)+ );+ &#125; &#125;&#125;);server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));复制代码</code></pre><h2 id="暂停上传"><a href="#暂停上传" class="headerlink" title="暂停上传"></a>暂停上传</h2><p>讲完了生成 hash 和文件秒传,回到断点续传</p><p>断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传</p><p>原理是使用 XMLHttpRequest 的 <code>abort</code> 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来,我们再改造一下 request 方法</p><pre><code> request(&#123; url, method = &quot;post&quot;, data, headers = &#123;&#125;, onProgress = e =&gt; e,+ requestList &#125;) &#123; return new Promise(resolve =&gt; &#123; const xhr = new XMLHttpRequest(); xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key =&gt; xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e =&gt; &#123;+ // 将请求成功的 xhr 从列表中删除+ if (requestList) &#123;+ const xhrIndex = requestList.findIndex(item =&gt; item === xhr);+ requestList.splice(xhrIndex, 1);+ &#125; resolve(&#123; data: e.target.response &#125;); &#125;;+ // 暴露当前 xhr 给外部+ requestList?.push(xhr); &#125;); &#125;,复制代码</code></pre><p>这样在上传切片时传入 requestList 数组作为参数,request 方法就会将所有的 xhr 保存在数组中了<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f8563491ea7793?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存<code>正在上传切片的 xhr</code></p><p>之后新建一个暂停按钮,当点击按钮时,调用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上传的切片</p><pre><code> handlePause() &#123; this.requestList.forEach(xhr =&gt; xhr?.abort()); this.requestList = [];&#125;复制代码</code></pre><p><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f890e598dd6afb?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="image-20200109143737924"><br>点击暂停按钮可以看到 xhr 都被取消了<br><img src="https://user-gold-cdn.xitu.io/2020/1/8/16f85644dc27459e?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"></p><h2 id="恢复上传"><a href="#恢复上传" class="headerlink" title="恢复上传"></a>恢复上传</h2><p>之前在介绍断点续传的时提到使用第二种服务端存储的方式实现续传</p><p>由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果</p><p>而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果</p><ul><li>服务端已存在该文件,不需要再次上传</li><li>服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把<strong>已上传</strong>的文件切片返回给前端</li></ul><p>所以我们改造一下之前文件秒传的服务端验证接口</p><pre><code>const extractExt = filename =&gt; filename.slice(filename.lastIndexOf(&quot;.&quot;), filename.length); // 提取后缀名const UPLOAD_DIR = path.resolve(__dirname, &quot;..&quot;, &quot;target&quot;); // 大文件存储目录const resolvePost = req =&gt; new Promise(resolve =&gt; &#123; let chunk = &quot;&quot;; req.on(&quot;data&quot;, data =&gt; &#123; chunk += data; &#125;); req.on(&quot;end&quot;, () =&gt; &#123; resolve(JSON.parse(chunk)); &#125;); &#125;); + // 返回已经上传切片名列表+ const createUploadedList = async fileHash =&gt;+ fse.existsSync(`$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;`)+ ? await fse.readdir(`$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;`)+ : [];server.on(&quot;request&quot;, async (req, res) =&gt; &#123; if (req.url === &quot;/verify&quot;) &#123; const data = await resolvePost(req); const &#123; fileHash, filename &#125; = data; const ext = extractExt(filename); const filePath = `$&#123;UPLOAD_DIR&#125;/$&#123;fileHash&#125;$&#123;ext&#125;`; if (fse.existsSync(filePath)) &#123; res.end( JSON.stringify(&#123; shouldUpload: false &#125;) ); &#125; else &#123; res.end( JSON.stringify(&#123; shouldUpload: true,+ uploadedList: await createUploadedList(fileHash) &#125;) ); &#125; &#125;&#125;);server.listen(3000, () =&gt; console.log(&quot;正在监听 3000 端口&quot;));复制代码</code></pre><p>接着回到前端,前端有两个地方需要调用验证的接口</p><ul><li>点击上传时,检查是否需要上传和已上传的切片</li><li>点击暂停后的恢复上传,返回已上传的切片</li></ul><p>新增恢复按钮并改造原来上传切片的逻辑</p><pre><code>&lt;template&gt; &lt;div id=&quot;app&quot;&gt; &lt;input type=&quot;file&quot; @change=&quot;handleFileChange&quot; /&gt; &lt;el-button @click=&quot;handleUpload&quot;&gt;上传&lt;/el-button&gt; &lt;el-button @click=&quot;handlePause&quot; v-if=&quot;isPaused&quot;&gt;暂停&lt;/el-button&gt;+ &lt;el-button @click=&quot;handleResume&quot; v-else&gt;恢复&lt;/el-button&gt; //... &lt;/div&gt;&lt;/template&gt;+ async handleResume() &#123;+ const &#123; uploadedList &#125; = await this.verifyUpload(+ this.container.file.name,+ this.container.hash+ );+ await this.uploadChunks(uploadedList); &#125;, async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList);+ const &#123; shouldUpload, uploadedList &#125; = await this.verifyUpload( this.container.file.name, this.container.hash ); if (!shouldUpload) &#123; this.$message.success(&quot;秒传:上传成功&quot;); return; &#125; this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123; fileHash: this.container.hash, index, hash: this.container.hash + &quot;-&quot; + index, chunk: file, percentage: 0 &#125;));+ await this.uploadChunks(uploadedList); &#125;, // 上传切片,同时过滤已上传的切片+ async uploadChunks(uploadedList = []) &#123; const requestList = this.data+ .filter((&#123; hash &#125;) =&gt; !uploadedList.includes(hash)) .map((&#123; chunk, hash, index &#125;) =&gt; &#123; const formData = new FormData(); formData.append(&quot;chunk&quot;, chunk); formData.append(&quot;hash&quot;, hash); formData.append(&quot;filename&quot;, this.container.file.name); formData.append(&quot;fileHash&quot;, this.container.hash); return &#123; formData, index &#125;; &#125;) .map(async (&#123; formData, index &#125;) =&gt; this.request(&#123; url: &quot;http://localhost:3000&quot;, data: formData, onProgress: this.createProgressHandler(this.data[index]), requestList: this.requestList &#125;) ); await Promise.all(requestList); // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时 // 合并切片+ if (uploadedList.length + requestList.length === this.data.length) &#123; await this.mergeRequest();+ &#125; &#125;复制代码</code></pre><p>![image-20200109144331326](data:image&#x2F;svg+xml;utf8,<?xml version=&quot;1.0&quot;?><svg xmlns=&quot;http://www.w3.org/2000/svg&quot; version=&quot;1.1&quot; width=&quot;463&quot; height=&quot;54&quot;></svg>)<br>这里给原来上传切片的函数新增 uploadedList 参数,即上图中服务端返回的切片名列表,通过 filter 过滤掉已上传的切片,并且由于新增了已上传的部分,所以之前合并接口的触发条件做了一些改动</p><p>到这里断点续传的功能基本完成了</p><h2 id="进度条改进"><a href="#进度条改进" class="headerlink" title="进度条改进"></a>进度条改进</h2><p>虽然实现了断点续传,但还需要修改一下进度条的显示规则,否则在暂停上传&#x2F;接收到已上传切片时的进度条会出现偏差</p><h3 id="切片进度条-1"><a href="#切片进度条-1" class="headerlink" title="切片进度条"></a>切片进度条</h3><p>由于在点击上传&#x2F;恢复上传时,会调用验证接口返回已上传的切片,所以需要将已上传切片的进度变成 100%</p><pre><code> async handleUpload() &#123; if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList); const &#123; shouldUpload, uploadedList &#125; = await this.verifyUpload( this.container.file.name, this.container.hash ); if (!shouldUpload) &#123; this.$message.success(&quot;秒传:上传成功&quot;); return; &#125; this.data = fileChunkList.map((&#123; file &#125;, index) =&gt; (&#123; fileHash: this.container.hash, index, hash: this.container.hash + &quot;-&quot; + index, chunk: file,+ percentage: uploadedList.includes(index) ? 100 : 0 &#125;)); await this.uploadChunks(uploadedList); &#125;,复制代码</code></pre><p>uploadedList 会返回已上传的切片,在遍历所有切片时判断当前切片是否在已上传列表里即可</p><h3 id="文件进度条-1"><a href="#文件进度条-1" class="headerlink" title="文件进度条"></a>文件进度条</h3><p>之前说到文件进度条是一个计算属性,根据所有切片的上传进度计算而来,这就遇到了一个问题<br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f8845dcca1427c?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>点击暂停会取消并清空切片的 xhr 请求,此时如果已经上传了一部分,就会发现文件进度条有<code>倒退</code>的现象<br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f884633f3acfd9?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>当点击恢复时,由于重新创建了 xhr 导致切片进度清零,所以总进度条就会倒退</p><p>解决方案是创建一个“假”的进度条,这个假进度条基于文件进度条,但只会停止和增加,然后给用户展示这个假的进度条</p><p>这里我们使用 Vue 的监听属性</p><pre><code> data: () =&gt; (&#123;+ fakeUploadPercentage: 0 &#125;), computed: &#123; uploadPercentage() &#123; if (!this.container.file || !this.data.length) return 0; const loaded = this.data .map(item =&gt; item.size * item.percentage) .reduce((acc, cur) =&gt; acc + cur); return parseInt((loaded / this.container.file.size).toFixed(2)); &#125; &#125;, watch: &#123;+ uploadPercentage(now) &#123;+ if (now &gt; this.fakeUploadPercentage) &#123;+ this.fakeUploadPercentage = now;+ &#125; &#125; &#125;,复制代码</code></pre><p>当 uploadPercentage 即真的文件进度条增加时,fakeUploadPercentage 也增加,一旦文件进度条后退,假的进度条只需停止即可</p><p>至此一个大文件上传 + 断点续传的解决方案就完成了</p><h1 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h1><p>大文件上传</p><ul><li>前端上传大文件时使用 Blob.prototype.slice 将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片</li><li>服务端接收切片并存储,收到合并请求后使用 fs.appendFileSync 对多个切片进行合并</li><li>原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听</li><li>使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度</li></ul><p>断点续传</p><ul><li>使用 spark-md5 根据文件内容算出文件 hash</li><li>通过 hash 可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)</li><li>通过 XMLHttpRequest 的 abort 方法暂停切片的上传</li><li>上传前服务端返回已经上传的切片名,前端跳过这些切片的上传</li></ul><h1 id="源代码"><a href="#源代码" class="headerlink" title="源代码"></a>源代码</h1><p>源代码增加了一些按钮的状态,交互更加友好,文章表达比较晦涩的地方可以跳转到源代码查看</p><p><a href="https://github.com/yeyan1996/file-upload">file-upload</a><br><img src="https://user-gold-cdn.xitu.io/2020/1/9/16f8859bdc09fa25?imageView2/0/w/1280/h/960/format/webp/ignore-error/1" alt="xuehuayu.cn"><br>谢谢观看 :)</p><h1 id="字节跳动-EA(Enterprise-Application)前端团队招人啦~"><a href="#字节跳动-EA(Enterprise-Application)前端团队招人啦~" class="headerlink" title="字节跳动 EA(Enterprise Application)前端团队招人啦~"></a>字节跳动 EA(Enterprise Application)前端团队招人啦~</h1><p>坐标上海&#x2F;北京,校招社招都有,hc 无上限,有兴趣的欢迎投递简历至 <a href="mailto:&#49;&#57;&#57;&#x36;&#x79;&#101;&#x79;&#x61;&#x6e;&#x40;&#x67;&#109;&#x61;&#x69;&#108;&#46;&#x63;&#x6f;&#109;">&#49;&#57;&#57;&#x36;&#x79;&#101;&#x79;&#x61;&#x6e;&#x40;&#x67;&#109;&#x61;&#x69;&#108;&#46;&#x63;&#x6f;&#109;</a>,校招内推码 Q7QUGMV</p><h1 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h1><p><a href="https://juejin.im/post/5da14778f265da5bb628e590">写给新手前端的各种文件上传攻略,从小图片到大文件断点续传</a></p><p><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/slice">Blob.slice</a><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/slice">developer.mozilla.org&#x2F;zh-CN&#x2F;docs&#x2F;…</a>)</p>]]></content>
2539
2539
 
2540
2540
 
2541
2541
  <categories>
@@ -5714,7 +5714,7 @@
5714
5714
  <link href="/article/53962.html"/>
5715
5715
  <url>/article/53962.html</url>
5716
5716
 
5717
- <content type="html"><![CDATA[<p>React16.3.0开始,生命周期进行了一些变化。本文主要介绍React16.3.0之后的生命周期。<br>16版本之前的react组件的生命周期相信大家已经很熟悉。16版本的react对组件的生命周期函数进行了一些修改,下面进行详细说明。</p><ul><li><a href="https://github.com/YvetteLau/Blog/issues/3">React新旧生命周期一览</a><span id="more"></span></li></ul><h3 id="React16-3-0之前生命周期"><a href="#React16-3-0之前生命周期" class="headerlink" title="React16.3.0之前生命周期"></a>React16.3.0之前生命周期</h3><blockquote><p>创建期:</p></blockquote><ol><li>constructor(props, context)</li><li>componentWillMount()</li><li>render()</li><li>componentDidMount()</li></ol><blockquote><p>运行时:</p></blockquote><p><strong>props发生变化时</strong></p><ol><li>componentWillReceiveProps(nextProps, nextContext)</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p><strong>state发生变化时</strong></p><ol><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><blockquote><p>卸载时</p></blockquote><p>componentWillUnmount()</p><h3 id="React16-3-0之后的生命周期"><a href="#React16-3-0之后的生命周期" class="headerlink" title="React16.3.0之后的生命周期"></a>React16.3.0之后的生命周期</h3><blockquote><p>创建期:</p></blockquote><ol><li>constructor(props, context)</li><li>static getDerivedStateFromProps(props, status)</li><li>render()</li><li>componentDidMount()</li></ol><p>或者如下生命周期:</p><ol><li>constructor(props, context)</li><li>componentWillMount() &#x2F; UNSAFE_componentWillMount()</li><li>render()</li><li>componentDidMount()</li></ol><p>注意: getDerivedStateFromProps&#x2F;getSnapshotBeforeUpdate 和 componentWillMount&#x2F;componentWillReceiveProps&#x2F;componentWillUpdate 如果同时存在,React会在控制台给出警告信息,且仅执行 getDerivedStateFromProps&#x2F;getSnapshotBeforeUpdate 【<a href="mailto:&#x52;&#101;&#x61;&#x63;&#x74;&#64;&#x31;&#54;&#46;&#x37;&#x2e;&#48;">&#x52;&#101;&#x61;&#x63;&#x74;&#64;&#x31;&#54;&#46;&#x37;&#x2e;&#48;</a>】</p><blockquote><p>运行时:</p></blockquote><p><strong>props发生变化时</strong></p><ol><li>static getDerivedStateFromProps(props, status)</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>getSnapshotBeforeUpdate(prevProps, prevState)</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p>或者如下生命周期:</p><ol><li>componentWillReceiveProps(nextProps, nextContext)&#x2F;UNSAFE_componentWillReceiveProps</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p><strong>state发生变化时</strong></p><ol><li>static getDerivedStateFromProps(props, status)</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>getSnapshotBeforeUpdate(prevProps, prevState)</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p>或者如下生命周期:</p><ol><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)&#x2F;UNSAFE_componentWillUpdate</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><blockquote><p>销毁时</p></blockquote><p>componentWillUnmount()</p><p>新的生命周期图示:</p><p><a href="https://camo.githubusercontent.com/1077b94360d6754685ea2052ab5ca32c9c338bf7/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434537633738613536653634323335626364626434323638363861346133383762332f3239353236"><img src="https://camo.githubusercontent.com/1077b94360d6754685ea2052ab5ca32c9c338bf7/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434537633738613536653634323335626364626434323638363861346133383762332f3239353236" alt="lifeCycle"></a></p><h2 id="生命周期详解"><a href="#生命周期详解" class="headerlink" title="生命周期详解"></a>生命周期详解</h2><h4 id="1-constructor-props-context"><a href="#1-constructor-props-context" class="headerlink" title="1.constructor(props, context)"></a>1.constructor(props, context)</h4><p>constructor生命周期,如不需要,可缺省。通常会在 constructor 方法中初始化 state 和绑定事件处理程序。</p><p>但是,如果写了constructor,那么必须在其中调用super(props);否则可能会引起报错。</p><p>如:</p><pre><code>class Base extends Component &#123; constructor(props) &#123; super(); //应该为 super(props); &#125; state = &#123; name: this.props.name &#125; //....code&#125;</code></pre><p>抛出异常: Uncaught TypeError: Cannot read property ‘name’ of undefined.</p><p>同样,如果定义了context,在state中需要使用this.context去获取context上的内容,则需要super(props, context);</p><p>不过,如果你缺省constructor,那么在state中,可以放心的使用 this.props 或者是 this.context,不会引起报错。</p><pre><code>class Base extends Component &#123; state = &#123; name: this.props.name, color: this.context.color &#125; //....code&#125;</code></pre><p>初始化的state同样可以在constructor中定义。如果需要给方法绑定this,那么统一在constructor中进行。</p><h4 id="2-static-getDerivedStateFromProps-props-state"><a href="#2-static-getDerivedStateFromProps-props-state" class="headerlink" title="2.static getDerivedStateFromProps(props, state)"></a>2.static getDerivedStateFromProps(props, state)</h4><p>当组件的state需要根据props来改变的时候可调用此方法。这个方法是在 render() 前会被执行,每次触发render前,都会触发此方法。</p><p>该方法有两个参数props和state; 返回值为state对象, 不需要返回整体state,把需要改变的state返回即可。如果不需要,可以返回null.</p><pre><code>classBaseextendsComponent &#123; state = &#123; age:20 &#125; staticgetDerivedStateFromProps(props, state) &#123; return &#123; age:50 &#125; &#125; render() &#123; // 50return ( &lt;div&gt;&#123;this.state.age&#125;&lt;/div&gt; ) &#125;&#125;</code></pre><p>这个方法允许组件基于 props 的变更来更新其内部状态,以这种方式获得的组件状态被称为派生状态。应该谨慎使用派生状态,可能会引入潜在的错误</p><h4 id="3-render"><a href="#3-render" class="headerlink" title="3.render"></a>3.render</h4><p>React组件中必须要提供的方法。当state或者props任一数据有更新时都会执行。</p><p>render() 是一个纯函数,因此,不要在其中执行setState诸如此类的操作。render必须有一个返回值,返回的数据类型可以有:</p><ul><li>null、String、Number、Array、Boolean。</li><li>React elements</li><li>Fragment</li><li>Portal</li></ul><p>注意不要在render中调用setState</p><h4 id="4-componentDidMount"><a href="#4-componentDidMount" class="headerlink" title="4.componentDidMount"></a>4.componentDidMount</h4><p>componentDidMount()方法是在组件加载完后立即执行,也就是当该组件相关的dom节点插入到dom树中时。该方法在组件生命中只执行一次。</p><p>一般情况,我们会在这里setState(),或者进行接口请求,设置订阅等。</p><pre><code>classBaseextendsComponent &#123; state = &#123; age:20 &#125; componentDidMount() &#123; this.fetchDate(); &#125; render() &#123; return ( &lt;div&gt;&#123;this.state.age&#125;&lt;/div&gt; ) &#125; //other code&#125;</code></pre><h4 id="5-shouldComponentUpdate-nextProps-nextState-nextContext"><a href="#5-shouldComponentUpdate-nextProps-nextState-nextContext" class="headerlink" title="5.shouldComponentUpdate(nextProps, nextState, nextContext)"></a>5.shouldComponentUpdate(nextProps, nextState, nextContext)</h4><p>在渲染新的props或state前,shouldComponentUpdate被调用,默认返回true。forceUpdate()时不会调用此方法。</p><p>如果shouldComponentUpdate()返回false,那么getSnapshotBeforeUpdate,render和componentDidUpdate不会被调用。</p><p>此生命周期主要用于优化性能。</p><h4 id="6-getSnapshotBeforeUpdate-prevProps-prevState"><a href="#6-getSnapshotBeforeUpdate-prevProps-prevState" class="headerlink" title="6.getSnapshotBeforeUpdate(prevProps, prevState)"></a>6.getSnapshotBeforeUpdate(prevProps, prevState)</h4><p>在render()的输出被渲染到DOM之前被调用。使组件能够在它们被更改之前捕获当前值(如滚动位置)。这个生命周期返回的任何值都将作为第三个参数传递给componentDidUpdate().</p><h4 id="7-componentDidUpdate-prevProps-prevState-snapshot"><a href="#7-componentDidUpdate-prevProps-prevState-snapshot" class="headerlink" title="7.componentDidUpdate(prevProps, prevState, snapshot)"></a>7.componentDidUpdate(prevProps, prevState, snapshot)</h4><p>在更新发生后调用 componentDidUpdate()。当组件更新时,将此作为一个机会来操作DOM。如将当前的props与以前的props进行比较(例如,如果props没有改变,则可能不需要网络请求。</p><p>如果组件使用 getSnapshotBeforeUpdate(),则它返回的值将作为第三个“快照”参数传递给 componentDidUpdate()。否则,这个参数是undefined。</p><h4 id="8-componentWillUnmount"><a href="#8-componentWillUnmount" class="headerlink" title="8.componentWillUnmount()"></a>8.componentWillUnmount()</h4><p>在组件被卸载并销毁之前立即被调用。在此方法中执行任何必要的清理,例如使定时器无效,取消网络请求或清理在componentDidMount()中创建的任何监听。</p><p><strong>最后,说明一点:</strong></p><p>componentWillMount,componentWillReceiveProps,componentWillUpdate这三个生命周期在React未来版本中会被废弃。</p><p>而UNSAFE_componentWillUpdate,UNSAFE_componentWillReceiveProps,UNSAFE_componentWillUpdate 未来版本中仍可继续使用。</p><p>初始化阶段(父组件和子组件):</p><p><a href="https://camo.githubusercontent.com/76fe4b48fa45ef5e389eed9098cc22fa483efabc/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434562303335306262373362633661396165346163313136303131393863313766322f3239353233"><img src="https://camo.githubusercontent.com/76fe4b48fa45ef5e389eed9098cc22fa483efabc/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434562303335306262373362633661396165346163313136303131393863313766322f3239353233" alt="mount"></a></p><p>运行阶段:父组件props&#x2F;state更新</p><p>子组件的shouldComponentUpdate返回false,则子组件其后的生命周期都不在进行,但是父组件的生命周期继续执行。</p><p><a href="https://camo.githubusercontent.com/9ecc4121b82ff19a4a31d4c5cc1785e7893f03d2/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434561643737363439393431396531633937386133633534336561313364393334652f3239353234"><img src="https://camo.githubusercontent.com/9ecc4121b82ff19a4a31d4c5cc1785e7893f03d2/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434561643737363439393431396531633937386133633534336561313364393334652f3239353234" alt="update"></a></p><p>卸载阶段: 卸载父组件</p><p><a href="https://user-images.githubusercontent.com/20203157/54731656-792d4e00-4bca-11e9-9f61-53fe34688878.png"><img src="https://user-images.githubusercontent.com/20203157/54731656-792d4e00-4bca-11e9-9f61-53fe34688878.png" alt="unmount"></a></p><p>参考:</p><ol><li><a href="https://segmentfault.com/a/1190000016935092">https://segmentfault.com/a/1190000016935092</a></li><li><a href="https://www.jianshu.com/p/514fe21b9914">https://www.jianshu.com/p/514fe21b9914</a></li><li><a href="https://blog.csdn.net/qq_29311407/article/details/79861522">https://blog.csdn.net/qq_29311407&#x2F;article&#x2F;details&#x2F;79861522</a></li></ol>]]></content>
5717
+ <content type="html"><![CDATA[<p>React16.3.0开始,生命周期进行了一些变化。本文主要介绍React16.3.0之后的生命周期。<br>16版本之前的react组件的生命周期相信大家已经很熟悉。16版本的react对组件的生命周期函数进行了一些修改,下面进行详细说明。</p><ul><li><a href="https://github.com/YvetteLau/Blog/issues/3">React新旧生命周期一览</a><span id="more"></span></li></ul><h3 id="React16-3-0之前生命周期"><a href="#React16-3-0之前生命周期" class="headerlink" title="React16.3.0之前生命周期"></a>React16.3.0之前生命周期</h3><blockquote><p>创建期:</p></blockquote><ol><li>constructor(props, context)</li><li>componentWillMount()</li><li>render()</li><li>componentDidMount()</li></ol><blockquote><p>运行时:</p></blockquote><p><strong>props发生变化时</strong></p><ol><li>componentWillReceiveProps(nextProps, nextContext)</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p><strong>state发生变化时</strong></p><ol><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><blockquote><p>卸载时</p></blockquote><p>componentWillUnmount()</p><h3 id="React16-3-0之后的生命周期"><a href="#React16-3-0之后的生命周期" class="headerlink" title="React16.3.0之后的生命周期"></a>React16.3.0之后的生命周期</h3><blockquote><p>创建期:</p></blockquote><ol><li>constructor(props, context)</li><li>static getDerivedStateFromProps(props, status)</li><li>render()</li><li>componentDidMount()</li></ol><p>或者如下生命周期:</p><ol><li>constructor(props, context)</li><li>componentWillMount() &#x2F; UNSAFE_componentWillMount()</li><li>render()</li><li>componentDidMount()</li></ol><p>注意: getDerivedStateFromProps&#x2F;getSnapshotBeforeUpdate 和 componentWillMount&#x2F;componentWillReceiveProps&#x2F;componentWillUpdate 如果同时存在,React会在控制台给出警告信息,且仅执行 getDerivedStateFromProps&#x2F;getSnapshotBeforeUpdate 【<a href="mailto:&#x52;&#x65;&#97;&#99;&#116;&#64;&#x31;&#54;&#46;&#55;&#x2e;&#48;">&#x52;&#x65;&#97;&#99;&#116;&#64;&#x31;&#54;&#46;&#55;&#x2e;&#48;</a>】</p><blockquote><p>运行时:</p></blockquote><p><strong>props发生变化时</strong></p><ol><li>static getDerivedStateFromProps(props, status)</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>getSnapshotBeforeUpdate(prevProps, prevState)</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p>或者如下生命周期:</p><ol><li>componentWillReceiveProps(nextProps, nextContext)&#x2F;UNSAFE_componentWillReceiveProps</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p><strong>state发生变化时</strong></p><ol><li>static getDerivedStateFromProps(props, status)</li><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>render</li><li>getSnapshotBeforeUpdate(prevProps, prevState)</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><p>或者如下生命周期:</p><ol><li>shouldComponentUpdate(nextProps, nextState, nextContext)</li><li>componentWillUpdate(nextProps, nextState, nextContext)&#x2F;UNSAFE_componentWillUpdate</li><li>render</li><li>componentDidUpdate(prevProps, prevState, snapshot)</li></ol><blockquote><p>销毁时</p></blockquote><p>componentWillUnmount()</p><p>新的生命周期图示:</p><p><a href="https://camo.githubusercontent.com/1077b94360d6754685ea2052ab5ca32c9c338bf7/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434537633738613536653634323335626364626434323638363861346133383762332f3239353236"><img src="https://camo.githubusercontent.com/1077b94360d6754685ea2052ab5ca32c9c338bf7/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434537633738613536653634323335626364626434323638363861346133383762332f3239353236" alt="lifeCycle"></a></p><h2 id="生命周期详解"><a href="#生命周期详解" class="headerlink" title="生命周期详解"></a>生命周期详解</h2><h4 id="1-constructor-props-context"><a href="#1-constructor-props-context" class="headerlink" title="1.constructor(props, context)"></a>1.constructor(props, context)</h4><p>constructor生命周期,如不需要,可缺省。通常会在 constructor 方法中初始化 state 和绑定事件处理程序。</p><p>但是,如果写了constructor,那么必须在其中调用super(props);否则可能会引起报错。</p><p>如:</p><pre><code>class Base extends Component &#123; constructor(props) &#123; super(); //应该为 super(props); &#125; state = &#123; name: this.props.name &#125; //....code&#125;</code></pre><p>抛出异常: Uncaught TypeError: Cannot read property ‘name’ of undefined.</p><p>同样,如果定义了context,在state中需要使用this.context去获取context上的内容,则需要super(props, context);</p><p>不过,如果你缺省constructor,那么在state中,可以放心的使用 this.props 或者是 this.context,不会引起报错。</p><pre><code>class Base extends Component &#123; state = &#123; name: this.props.name, color: this.context.color &#125; //....code&#125;</code></pre><p>初始化的state同样可以在constructor中定义。如果需要给方法绑定this,那么统一在constructor中进行。</p><h4 id="2-static-getDerivedStateFromProps-props-state"><a href="#2-static-getDerivedStateFromProps-props-state" class="headerlink" title="2.static getDerivedStateFromProps(props, state)"></a>2.static getDerivedStateFromProps(props, state)</h4><p>当组件的state需要根据props来改变的时候可调用此方法。这个方法是在 render() 前会被执行,每次触发render前,都会触发此方法。</p><p>该方法有两个参数props和state; 返回值为state对象, 不需要返回整体state,把需要改变的state返回即可。如果不需要,可以返回null.</p><pre><code>classBaseextendsComponent &#123; state = &#123; age:20 &#125; staticgetDerivedStateFromProps(props, state) &#123; return &#123; age:50 &#125; &#125; render() &#123; // 50return ( &lt;div&gt;&#123;this.state.age&#125;&lt;/div&gt; ) &#125;&#125;</code></pre><p>这个方法允许组件基于 props 的变更来更新其内部状态,以这种方式获得的组件状态被称为派生状态。应该谨慎使用派生状态,可能会引入潜在的错误</p><h4 id="3-render"><a href="#3-render" class="headerlink" title="3.render"></a>3.render</h4><p>React组件中必须要提供的方法。当state或者props任一数据有更新时都会执行。</p><p>render() 是一个纯函数,因此,不要在其中执行setState诸如此类的操作。render必须有一个返回值,返回的数据类型可以有:</p><ul><li>null、String、Number、Array、Boolean。</li><li>React elements</li><li>Fragment</li><li>Portal</li></ul><p>注意不要在render中调用setState</p><h4 id="4-componentDidMount"><a href="#4-componentDidMount" class="headerlink" title="4.componentDidMount"></a>4.componentDidMount</h4><p>componentDidMount()方法是在组件加载完后立即执行,也就是当该组件相关的dom节点插入到dom树中时。该方法在组件生命中只执行一次。</p><p>一般情况,我们会在这里setState(),或者进行接口请求,设置订阅等。</p><pre><code>classBaseextendsComponent &#123; state = &#123; age:20 &#125; componentDidMount() &#123; this.fetchDate(); &#125; render() &#123; return ( &lt;div&gt;&#123;this.state.age&#125;&lt;/div&gt; ) &#125; //other code&#125;</code></pre><h4 id="5-shouldComponentUpdate-nextProps-nextState-nextContext"><a href="#5-shouldComponentUpdate-nextProps-nextState-nextContext" class="headerlink" title="5.shouldComponentUpdate(nextProps, nextState, nextContext)"></a>5.shouldComponentUpdate(nextProps, nextState, nextContext)</h4><p>在渲染新的props或state前,shouldComponentUpdate被调用,默认返回true。forceUpdate()时不会调用此方法。</p><p>如果shouldComponentUpdate()返回false,那么getSnapshotBeforeUpdate,render和componentDidUpdate不会被调用。</p><p>此生命周期主要用于优化性能。</p><h4 id="6-getSnapshotBeforeUpdate-prevProps-prevState"><a href="#6-getSnapshotBeforeUpdate-prevProps-prevState" class="headerlink" title="6.getSnapshotBeforeUpdate(prevProps, prevState)"></a>6.getSnapshotBeforeUpdate(prevProps, prevState)</h4><p>在render()的输出被渲染到DOM之前被调用。使组件能够在它们被更改之前捕获当前值(如滚动位置)。这个生命周期返回的任何值都将作为第三个参数传递给componentDidUpdate().</p><h4 id="7-componentDidUpdate-prevProps-prevState-snapshot"><a href="#7-componentDidUpdate-prevProps-prevState-snapshot" class="headerlink" title="7.componentDidUpdate(prevProps, prevState, snapshot)"></a>7.componentDidUpdate(prevProps, prevState, snapshot)</h4><p>在更新发生后调用 componentDidUpdate()。当组件更新时,将此作为一个机会来操作DOM。如将当前的props与以前的props进行比较(例如,如果props没有改变,则可能不需要网络请求。</p><p>如果组件使用 getSnapshotBeforeUpdate(),则它返回的值将作为第三个“快照”参数传递给 componentDidUpdate()。否则,这个参数是undefined。</p><h4 id="8-componentWillUnmount"><a href="#8-componentWillUnmount" class="headerlink" title="8.componentWillUnmount()"></a>8.componentWillUnmount()</h4><p>在组件被卸载并销毁之前立即被调用。在此方法中执行任何必要的清理,例如使定时器无效,取消网络请求或清理在componentDidMount()中创建的任何监听。</p><p><strong>最后,说明一点:</strong></p><p>componentWillMount,componentWillReceiveProps,componentWillUpdate这三个生命周期在React未来版本中会被废弃。</p><p>而UNSAFE_componentWillUpdate,UNSAFE_componentWillReceiveProps,UNSAFE_componentWillUpdate 未来版本中仍可继续使用。</p><p>初始化阶段(父组件和子组件):</p><p><a href="https://camo.githubusercontent.com/76fe4b48fa45ef5e389eed9098cc22fa483efabc/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434562303335306262373362633661396165346163313136303131393863313766322f3239353233"><img src="https://camo.githubusercontent.com/76fe4b48fa45ef5e389eed9098cc22fa483efabc/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434562303335306262373362633661396165346163313136303131393863313766322f3239353233" alt="mount"></a></p><p>运行阶段:父组件props&#x2F;state更新</p><p>子组件的shouldComponentUpdate返回false,则子组件其后的生命周期都不在进行,但是父组件的生命周期继续执行。</p><p><a href="https://camo.githubusercontent.com/9ecc4121b82ff19a4a31d4c5cc1785e7893f03d2/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434561643737363439393431396531633937386133633534336561313364393334652f3239353234"><img src="https://camo.githubusercontent.com/9ecc4121b82ff19a4a31d4c5cc1785e7893f03d2/68747470733a2f2f6e6f74652e796f7564616f2e636f6d2f7977732f7075626c69632f7265736f757263652f66343537303163633431303530346537316462626362643838363165386430632f786d6c6e6f74652f5745425245534f5552434561643737363439393431396531633937386133633534336561313364393334652f3239353234" alt="update"></a></p><p>卸载阶段: 卸载父组件</p><p><a href="https://user-images.githubusercontent.com/20203157/54731656-792d4e00-4bca-11e9-9f61-53fe34688878.png"><img src="https://user-images.githubusercontent.com/20203157/54731656-792d4e00-4bca-11e9-9f61-53fe34688878.png" alt="unmount"></a></p><p>参考:</p><ol><li><a href="https://segmentfault.com/a/1190000016935092">https://segmentfault.com/a/1190000016935092</a></li><li><a href="https://www.jianshu.com/p/514fe21b9914">https://www.jianshu.com/p/514fe21b9914</a></li><li><a href="https://blog.csdn.net/qq_29311407/article/details/79861522">https://blog.csdn.net/qq_29311407&#x2F;article&#x2F;details&#x2F;79861522</a></li></ol>]]></content>
5718
5718
 
5719
5719
 
5720
5720
  <categories>